跳到主要内容

SpringSecurity 编写一个简单认证Demo

FIXME: 这篇笔记的 JWT 过滤器要修改一下,实际不能这么搞,因为引入 JWT 就是为了避免每次都查询数据库。下面这样还去查询数据库就太蠢了,以后可以使用 RSA 配置一个数字签名的认证

认证与鉴权

Spring Security 主要功能如下

  • 认证 Authentication
  • 授权 Authorization
  • 攻击防护

认证的方式也可以有多种多样

Authentication 常见的有如下几种

  • HTTP Authentication
  • Forms Authentication
  • Certificate
  • Tokens

编写一个 Resource

其实就是随便写一个 API

@RestController
public class HelloResource {

@GetMapping("/hello")
public String hello() {
return "this is resource";
}
}

编写 UserDetailsService

/**
* 编写一个自定义的 UserDetailsService 用来加载用户
* 注意,它一般不做密码校验,单纯是给 Security 其它组件
* 提供数据,至于密码校验是由 AuthenticationManager 完成的
**/
@Service
public class MyDetailsService implements UserDetailsService {

/**
* 这个 UserDetailsService 一般只用于到 DAO 层加载用户数据
*/
@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
// 这里使用官方提供的 User 类,第三个参数是权限列表,这里直接让它为空
return new User("foo", "foopassword", new ArrayList<>());
}
}

编写 SecurityConfigurer

/**
* 这里首先继承了 WebSecurityConfigurerAdapter,它是所有 Web配置的接入点
* Adapter 即适配器
* <p>
* 注意 @EnableWebSecurity 注解内置了 @Configurable
**/
@EnableWebSecurity
public class SecurityConfigurer extends WebSecurityConfigurerAdapter {

@Autowired
private MyDetailsService myDetailsService;

/**
* 顾名思义,就是建造者模式,它用来构建一个 AuthenticationManager
* 添加 UserDetailsService 和 AuthenticationProvider's 就在这里
* <p>
* 然后它还可以用来
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
// 不做具体的 AuthenticationManager 选择这里的默认使用 DaoAuthenticationConfigurer
// 这个 DetailsService 单纯就是从 Dao 层取得用户数据,它不进行密码校验
.userDetailsService(myDetailsService)
// 如果上面那个 userDetailsService 够简单其实可以像下面这样用 SQL 语句查询比对
// .dataSource(dataSource)
// .usersByUsernameQuery("Select * from users where username=?")
// 这个 passwordEncoder 配置的实际就是 DaoAuthenticationConfigurer 的加密器
.passwordEncoder(passwordEncoder());

}

@Bean
public PasswordEncoder passwordEncoder() {
// return new BCryptPasswordEncoder();
// 注意,虽然显示过时了,但是官方没有计划删除它,一般也就使用纯文本密码的测试时会用它
return NoOpPasswordEncoder.getInstance();
}
}

访问测试

输入项目地址访问

http://localhost:8080/hello

然后会自动跳转到登陆页面要求登陆(默认使用了 formLogin 这个过滤器)

整合 JWT

首先是导入依赖

<properties>
<jwt.version>0.10.7</jwt.version>
</properties>
<!-- ... -->

<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
<scope>runtime</scope>
</dependency>

创建一个 JWT 工具类

@Service
public class JwtUtil {

// 注意,这里使用 secretKeyFor 方法自动随机生成一个适合指定编码长度的密钥,避免硬编码出错,以及安全问题
private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 不过正式的开发环境,这个密钥最好不要这样搞,第一次生成之后记录下来就行了,不然每次重启服务一次,全部 JWT 都失效了

public String extractUsername(String token) {
// 这里直接引用 Claims 类里面的 getSubject 方法
return extractClaim(token, Claims::getSubject);
}

/*
它等价于下面这个
public String extractUsername(String token) {
return extractClaim(token, (Claims claims)-> {
return claims.getSubject();
});
}
*/

public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}

// 这个 Function 表示一个接受一个参数并产生结果的函数。
// <T> 函数输入的类型(就是 apply 方法的参数类型)
// <R> 函数结果的类型(就是 apply 方法的返回值)
public <R> R extractClaim(String token, Function<Claims, R> claimsResolver) {
final Claims claims = extractAllClaims(token);
// 这个 Function 函数接口通过调用 apply 取得结果
return claimsResolver.apply(claims);
}

private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
}

private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}

public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}

private String createToken(Map<String, Object> claims, String subject) {

return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
// 这里需要显示指定使用 HS256(注意,上面只是生成一个适合长度的密钥,本体它还是一个普通字串)
.signWith(SECRET_KEY, SignatureAlgorithm.HS256).compact();
}

public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
// 检查 token 里面的信息是否与 UserDetails 相同,这里可以写多几个认证,但是只是测试,所以象征性比对个用户名就行了
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}

创建 Model 实体

用于封装请求参数

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationRequest {
private String username;
private String password;
}

用于封装响应的 JWT

@AllArgsConstructor
@Getter
public class AuthenticationResponse {
private final String jwt;
}

创建一个认证端点

创建一个 /authenticate 接口专门用于生成 JWT,这里就直接加在上面那个 /hello 接口里面了

@Slf4j
@RestController
public class HelloResource {

@Autowired
private AuthenticationManager authenticationManager;

/*
@Autowired
private MyDetailsService myDetailsService; // 用于取得用户数据
*/


@Autowired
private JwtUtil jwtUtil;


@GetMapping("/hello")
public String hello() {
return "this is resource";
}


@PostMapping("/authenticate")
public ResponseEntity<AuthenticationResponse> createAuthenticateToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {
Authentication authenticate = null;
try {
// 这里一步就是校验用户身份,点进去这个 authenticate(默认是 ProviderManager 这个实现类)
// 它的参数类型是一个 Authentication,即传入一个未认证的 Authentication 进去,返回一个
// 已经认证的 Authentication 出来
authenticate = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
authenticationRequest.getUsername(),
authenticationRequest.getPassword()
)
);
} catch (BadCredentialsException e) {
throw new Exception("登陆错误", e);
}

// 补充知识 使用局部变量修饰 final 的好处
// 1、访问局部变量要比访问成员变量要快
// 2、访问局部变量要比每次调用方法去获取对象要快
// 3、使用final修饰可以避免变量被重新赋值(引用赋值)
// 4、使用final修饰时,JVM不用去跟踪该引用是否被更改?

// 其实如果上面已经认证通过了,这里的 (UserDetails) authenticate.getPrincipal() 其实也可以使用下面这个方式取得
// final UserDetails userDetails = myDetailsService.loadUserByUsername(authenticationRequest.getUsername());
// 不过有些时候会在 AuthenticationProvider 里面注入一些权限角色进这个 UserDetails 里面的 getAuthorities(); 方法里面
final String jwt = jwtUtil.generateToken((UserDetails) authenticate.getPrincipal());
return ResponseEntity.ok(new AuthenticationResponse(jwt));
}
}

编写一个 JWT 过滤器

/**
* OncePerRequestFilter 它能够确保在一次请求只通过一次 filter,而不需要重复执行
**/
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private MyDetailsService myDetailsService;

@Autowired
private JwtUtil jwtUtil;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 标准的 Token 都是从这个 Authorization 里面取得数据的
final String authorizationHeader = request.getHeader("Authorization");

String username = null;
String jwt = null;

// 注意,一般它前面还有一个 “Bearer ”
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
// 尝试拿 token 中的 username
// 若是没有 token 或者拿 username 时出现异常,那么 username 为 null
username = jwtUtil.extractUsername(jwt);
}

if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

// 用 UserDetailsService 从数据库中拿到用户的 UserDetails 类
// UserDetails 类是 Spring Security 用于保存用户权限的实体类
UserDetails userDetails = this.myDetailsService.loadUserByUsername(username);

// 检查用户带来的 token 是否有效
// 包括 token 和 userDetails 中用户名是否一样, token 是否过期, token 生成时间是否在最后一次密码修改时间之前
// 若是检查通过
if (jwtUtil.validateToken(jwt, userDetails)) {

// 生成通过认证的 Authentication
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
// 一般不在这里填入密码
userDetails, null, userDetails.getAuthorities());

// 将这个请求本体存入进去
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// 将权限写入本次会话,这个 Context 会在当前这个线程有效(它内部维护着一个 ThreadLocal)
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}

// 这里可以直接放行,反正后面 SpringSecurity 没有从上下文中取得这个请求的 usernamePasswordAuthenticationToken 也会将其拦截
chain.doFilter(request, response);
}
}

修改下 SecurityConfigurer

修改下上面的 SecurityConfigurer 配置类

@Override
public void configure(HttpSecurity http) throws Exception {
// 先关闭 CSRF 防护(跨站请求伪造,其实就是使用 Cookie 的那堆屁事,如果使用 JWT 可以直接关闭它)
http.csrf().disable()
.authorizeRequests()
// 这个 antMatcher 方法用于匹配请求(注意方法名后面要加 's')
.antMatchers(HttpMethod.POST, "/authenticate").permitAll()
.anyRequest().authenticated()
// 这里关闭 Session 验证(就是 Cookie-Session 那个)
.and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);

// 把自己注册的过滤器放在 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}

// 注意,早期使用这个 authenticationManager 是可以不用手动注册的,但是到了新版需要像这样手动注册
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}

编写 Mock 测试

// SpringBootTest 注解默认使用 webEnvironment = WebEnvironment.MOCK,它是不会对 Filter、Servlet进行初始化的。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// 这个注解可以用来自动注入 mockMvc,这里一定要使用这个注解来注入,不然默认的那种写法是没有添加 Filter的
@AutoConfigureMockMvc
class HelloResourceTest {

@Autowired
private WebApplicationContext webApplicationContext;
@Autowired
private MockMvc mockMvc;

private String jwt;
ObjectMapper objectMapper;

@BeforeEach
void setUp() throws Exception {
objectMapper = new ObjectMapper();
createAuthenticateToken(); // 生成 Token
}

@Test
void hello() throws Exception {
mockMvc.perform(
MockMvcRequestBuilders.get("/hello")
.header("Authorization", "Bearer " + jwt) // 别忘了要加个空格
)
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("this is resource"))
.andDo(MockMvcResultHandlers.print());
}

void createAuthenticateToken() throws Exception {

AuthenticationRequest authenticationRequest = new AuthenticationRequest("foo", "foopassword");
String json = objectMapper.writeValueAsString(authenticationRequest);

mockMvc.perform(
MockMvcRequestBuilders.post("/authenticate")
.accept(MediaType.APPLICATION_JSON_VALUE)
.contentType("application/json;charset=UTF-8")
.content(json.getBytes(StandardCharsets.UTF_8))
)
.andExpect(MockMvcResultMatchers.status().isOk())
// .andDo(MockMvcResultHandlers.print())
.andDo(result -> {
String body = result.getResponse().getContentAsString();
// 注意:最后这里要用 asText 不要用 toString,否则结果是有 " " 引号的
jwt = objectMapper.readTree(body).get("jwt").asText();
});
}
}

项目结构一览